Pro ASP.NET Core MVC2(第7版)翻译

第17章:控制器和 Action

作者:Adam Freeman 翻译:陈广 日期:2018-9-25


向应用程序发出的每个请求都由控制器处理。在ASP.NET Core MVC中,控制器是 .NET 类,包含处理请求所需的逻辑。在第三章中,我解释了控制器的角色是封装您的应用程序逻辑,这意味着控制器负责处理传入的请求,执行域模型上的操作,并选择呈现给用户的视图。

只要控制器不进入属于模型和视图的责任范围,就可以自由地处理它认为合适的请求。这意味着控制器不包含或存储数据,也不生成用户界面。

在本章中,我将向您展示控制器是如何实现的,以及使用控制器接收和生成输出的不同方式。表17-1为控制器简介。

表 17-1:控制器简介

问题 回答
它们是什么? 控制器包含用于接收请求、更新应用程序状态或模型、以及选择将发送给客户端的响应的逻辑。
它们有何用途? 控制器是 MVC 项目的核心,包含 web 应用程序的域逻辑。
如何使用它们 控制器是 C# 类,可调用它们的公共方法处理 HTTP 请求。方法可以直接负责向客户端生成响应,但是更常见的方法是返回一个 action 结果,它告诉 MVC 应该如何准备响应。
是否有任何缺陷或限制? 当您刚接触 MVC 开发时,可以很容易地创建包含更适合模型或视图的功能的控制器。一个更具体的问题是,任何名称以 Controller 结尾的公共类都被 MVC 假定为控制器;这意味着有可能在不打算成为控制器的类中意外地处理 HTTP 请求。
有没有其他选择? 没有,控制器是 MVC 应用程序的核心部分

表17-2为本章摘要。

表 17-2:本章摘要

问题 解决方案 清单
定义一个控制器 创建名称以 Controller 结尾或从Controller类派生的公共类。 7-9
获取 HTTP 请求的详细信息 使用 context 对象或定义 action 方法参数 10-13
从 action 方法中产生结果 直接使用结果 context 对象或创建 action 结果对象 14-16
生成 HTML 结果 创建视图结果 17-24
重定向客户端 创建一个重定向结果 25-30
返回到客户端的内容 创建一个内容结果 31-35
返回 HTTP 状态码 创建一个 HTTP 结果 36-37

译者注:从这篇文章开始,context不再翻译为“上下文”,而直接使用英文原文,这样无论是读还是看,都舒服顺眼多了。

准备示例项目

本章我使用【 ASP.NET Core Web 应用程序(.NET Core)】模板创建了一个新的名为 ControllersAndActions 的【空】项目,在清单17-1中,我向 Startup 类添加了语句,以启用 MVC 框架和其他中间件组件。

注意:本章包含关键功能的单元测试。为了简洁起见,我还没有在创建示例项目的说明中包含单元测试项目。您可以按照第7章描述的过程创建测试项目,也可以从本书的 GitHub 存储库(https://github.com/apress/pro-asp.net-core-mvc-2)下载该项目。

清单 17-1:ControllersAndActions 文件夹下的 Startup.cs 文件,添加 MVC 和其它中间件

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.DependencyInjection;

namespace ControllersAndActions
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();
            services.AddMemoryCache();
            services.AddSession();
        }

        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseStatusCodePages();
            app.UseDeveloperExceptionPage();
            app.UseStaticFiles();
            app.UseSession();
            app.UseMvcWithDefaultRoute();
        }
    }
}

AddMemoryCacheAddSession方法创建会话管理所需的服务。UseSession方法向管道中添加一个中间件组件,将会话数据与请求关联起来,并向响应中添加 cookies,以确保可以识别未来的请求。在UseMvc方法之前必须调用UseSession方法,以便会话组件能够在请求到达 MVC 中间件之前拦截它们,并在它们生成后修改响应。其他方法设置了我在第14章中描述的标准包。

准备视图

本章的重点是控制器及其 action 方法,我将定义在整个章节中使用的控制器类。为了做好准备,我将定义一些视图来帮助演示它们是如何工作的。我在本节中创建的视图是在 Views/Shared 文件夹中定义的,以便我可以从本章后面创建的任何控制器中使用它们。我创建了 Views/Shared 文件夹,向其添加了一个名为 Result.cshtml 的 Razor 视图文件,并应用了清单17-2所示的标记。

清单 17-2: Views/Shared 文件夹下的 Result.cshtml 文件的内容

@model string
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/twitter-bootstrap/css/*.min.css" />
</head>
<body class="m-1 p-1">
    Model Data: @Model
</body>
</html>

这个视图的模型是一个字符串,它允许我显示简单的消息。接下来,我在 Views/Shared 文件夹中创建了一个名为 DictionaryResult.cshtml 的文件,并添加了清单17-3所示的标记。

清单 17-3:Views/Shared 文件夹下的 DictionaryResult.cshtml 文件的内容

@model IDictionary<string, string>
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/twitter-bootstrap/css/*.min.css" />
</head>
<body class="m-1 p-1">
    <table class="table table-bordered table-sm table-striped">
        <tr><th>Name</th><th>Value</th></tr>
        @foreach (string key in Model.Keys)
        {
            <tr><td>@key</td><td>@Model[key]</td></tr>
        }
    </table>
</body>
</html>

接下来,我创建了一个名为 SimpleForm.cshtml 的文件,也是在 Views/Shared 文件夹,并使用它定义了清单17-4的视图。顾名思义,这个视图包含一个简单的 HTML 表单,它将从用户那收集数据。

清单 17-4:Views/Shared 文件夹下的 SimpleForm.cshtml 文件的内容

@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/twitter-bootstrap/css/*.min.css" />
</head>
<body class="m-1 p-1">
    <form method="post" asp-action="ReceiveForm">
        <div class="form-group">
            <label for="name">Name:</label>
            <input class="form-control" name="name" />
        </div>
        <div class="form-group">
            <label for="name">City:</label>
            <input class="form-control" name="city" />
        </div>
        <button class="btn btn-primary center-block" type="submit">Submit</button>
    </form>
</body>
</html>

视图使用内置标签助手从路由系统生成 URL。为了启用标签助手,我在 Views 文件夹中创建了一个名为 _ViewImports.cshtml 的视图导入文件,并添加了清单17-5所示的表达式。

清单 17-5:Views 文件夹下的 _ViewImports.cshtml 文件的内容

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers

我在 Views/Shared 文件夹中创建的视图都依赖于 Bootstrap CSS 包。要将 Bootstrap 添加到项目中,我在 ControllersAndActions 项目中单击鼠标右键,在弹出菜单中选择【添加】➤【添加客户端库】,并将 twitter-bootstrap 添加至项目中。最终生成的 libman.json 配置文件代码如下:

清单 17-6:UrlsAndRoutes 文件夹下的 libman.json 文件,添加 Bootstrap 包

{
  "version": "1.0",
  "defaultProvider": "cdnjs",
  "libraries": [
    {
      "library": "twitter-bootstrap@4.1.3",
      "destination": "wwwroot/lib/twitter-bootstrap/"
    }
  ]
}

理解控制器

控制器是 C# 类,它的公共方法(被称为 actionsaction 方法)负责处理 HTTP 请求,并准备返回给客户端的响应。MVC 使用第15章和第16章中描述的路由系统来确定处理请求所需的控制器类和 action 方法。MVC 创建控制器类的新实例,调用 action 方法,并使用该方法的结果生成对客户端的响应。

MVC 提供了带有 context 数据的 action 方法,这样它们就可以知道如何处理请求。有各种各样的 context 数据可用,它描述了关于当前请求的一切,正在准备的响应,路由系统提取的数据,以及用户身份的详细信息。

当 MVC 调用一个 action 方法时,该方法的响应描述了应该发送给客户端的响应。最常见的响应类型是通过渲染 Razor 视图来创建的,因此 action 方法使用它的响应来告诉 MVC 应该使用哪个视图以及应该提供什么样的视图模型数据。但是也有其他类型的响应可用,action 方法可以完成从要求 MVC 向客户端发送 HTTP 重定向到发送复杂数据对象的任何事情。

这意味着有三个功能区域对理解控制器很重要。第一个是了解如何定义控制器,以便 MVC 可以使用它们来处理请求。控制器仅仅是 C# 类,但是有不同的方法来创建它们,理解它们之间的差异是很重要的。我在《创建控制器》这一节解释了如何定义控制器。

第二,了解 MVC 如何为 action 方法提供 context 数据是非常重要的。获取所需的 context 数据对于 web 应用程序的有效开发非常重要,但是 MVC 通过定义一组用于描述 action 方法所需的所有内容的类使它变得容易。我在《接收 context 数据》一节中解释了 MVC 如何描述请求和响应。

最后,了解 action 方法如何产生响应是很重要的。action 方法本身很少需要生成 HTTP 响应,您需要知道如何指示 MVC 生成所需的响应,我在《生成响应》一节中解释了这一点。

创建控制器

到目前为止,你已经在几乎所有章节中都看到了控制器的使用。现在是时候退一步,看看幕后的定义了。在接下来的章节中,我描述了创建控制器的不同方法,并解释了它们之间的区别。

创建 POCO 控制器

MVC 支持约定覆盖配置,这意味着 MVC 应用程序中的控制器是自动发现的,而不是在配置文件中定义的。基本的发现过程很简单:任何名称以“Controller”结尾的公共类都是一个控制器,它定义的任何公共方法都是一个 action。为了演示这是如何工作的,我向项目中添加了一个 Controllers 文件夹,并向它添加了一个名为 PocoController.cs 的类文件,用于定义清单17-7所示的类

提示:虽然约定是将控制器放在 Controllers 文件夹中,但是您可以将它们放在项目中的任何地方,而 MVC 仍然会找到它们。

清单 17-7:Controllers 文件夹下的 PocoController.cs 文件的内容

namespace ControllersAndActions.Controllers
{
    public class PocoController
    {
        public string Index() => "This is a POCO controller";
    }
}

PocoController类满足 MVC 在控制器中查找的简单条件。它定义了一个称为Index的公共方法,它将用作 action 方法,并返回一个字符串。

PocoController类是 POCO 控制器的一个例子,其中 POCO 的意思是“普通的旧 CLR 对象”,它引用了使用标准 .NET 特性实现的实际控制器,而不直接依赖 ASP.NET Core MVC 提供的 API。

要测试 POCO 控制器,启动应用程序并请求URL /Poco/Index。路由系统将使用默认的 URL 模式匹配请求,并将请求定向到PocoController类的Index方法,产生如图17-1所示的结果。

图17-1 使用 POCO 控制器


使用特性调整控制器识别

对 POCO 控制器的支持并不总是以您想要的方式工作。一个常见的问题是 MVC 将识别为单元测试创建的伪类作为控制器。避免这个问题的最简单的方法是注意您的类的名称,并避免像FakeController这样的名称。如果必须要这样做,那么您可以将Microsoft.AspNetCore.Mvc命名空间中定义的非控制器属性应用到一个类中,告诉 MVC 它不是控制器。还有一个NonAction特性可以应用于方法,以阻止它们被用作 action 方法。


使用 MVC 控制器 API

PocoController类很好地演示了 MVC 识别控制器的方式以及控制器的简单程度。但是,不依赖于Microsoft.AspnetCore命名空间的纯 POCO 控制器并不十分有用,因为它们无法访问 MVC 为处理请求提供的特性。

可以通过从Microsoft.AspnetCore命名空间创建新的类实例来访问 MVC API 的某些部分。作为一个简单的例子,POCO 类可以通过从它的 action 方法返回ViewResult对象来请求 MVC 呈现一个 Razor 视图,如清单17-8所示。(我回到了《生成响应》这一节中的ViewResult类)。

清单 17-8:Controllers 文件夹下的 PocoController.cs 文件,使用 ASP.NET API

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures;

namespace ControllersAndActions.Controllers
{
    public class PocoController
    {
        public ViewResult Index() => new ViewResult()
        {
            ViewName = "Result",
            ViewData = new ViewDataDictionary(
                new EmptyModelMetadataProvider(),
                new ModelStateDictionary())
            {
                Model = $"This is a POCO controller"
            }
        };
    }
}

这不再是一个纯粹的 POCO 控制器,因为它与 MVC API 有直接的依赖关系。但是,除此之外,它比前面的示例要有用得多,因为它要求 MVC 呈现 Razor 视图。不幸的是,代码是复杂的。要创建ViewResult对象,我需要创建ViewDataDictionaryEmptyModelMetadataProviderModelStateDictionary,这需要访问三个不同的名称空间(我在后面的章节中描述了这些类型相关的特性)。这个例子的要点是演示 MVC 提供的特性可以直接访问,即使结果有点混乱。

清单中的更改为使用一个字符串作为视图模型来渲染 Result.cshtml 视图。如果您运行应用程序并请求 /Poco/Index URL,将看到如图17-2所示的响应。

图17-2 直接使用 MVC API

使用控制器基类

面的示例展示了如何从 POCO 控制器开始,并在其基础上访问 MVC 功能。这种方法揭示了 MVC 是如何工作的,如果您发现自己无意中创建了控制器,而 POCO 控制器在编写、读取和维护上却很笨拙,这是非常有用的知识。

创建控制器的一种更简单的方法是从Microsoft.AspNetCore.Mvc.Controller类派生,该类定义方法和属性,这些方法和属性以更简洁和有用的方式提供对 MVC 特性的访问。为演示这些,我在 Controllers 文件夹中添加了一个名为 DerivedController.cs 的类文件,并使用它定义了如清单17-9所示的控制器。

清单 17-9:Controllers 文件夹下的 DerivedController.cs 文件,从控制器类派生

using Microsoft.AspNetCore.Mvc;

namespace ControllersAndActions.Controllers
{
    public class DerivedController : Controller
    {
        public ViewResult Index() =>
            View("Result", $"This is a derived controller");
    }
}

如果运行应用程序并请求 /Derived/Index URL,您将看到如图17-3所示的结果。

图17-3 使用控制器基类

清单17-9中的控制器所做的事情与清单17-8中的控制器相同(它要求 MVC 用字符串视图模型渲染视图),但是使用控制器基类意味着可以更简单地实现结果。

关键的更改是,我可以使用View方法创建渲染 Razor 视图所需的ViewResult对象,而不必直接在 action 方法中实例化它以及它所需的其他类型。视图方法是从Controller基类继承而来的,并且ViewResult对象仍然是以相同的方式创建的,只是没有代码干扰了我的 action 方法。从Controller类派生不会改变控制器的工作方式;它只是简化了您编写的代码,以完成常见的任务。

注意:MVC 为每个被要求处理的请求创建一个控制器类的新实例。这意味着您不需要同步访问 action 方法或实例属性和字段。共享对象,包括我在第18章中描述的数据库和单例服务,可以同时使用,并且必须相应地编写。

接收 Context 数据

不管您如何定义控制器,它们很少单独存在,通常需要从传入请求中访问数据,如路由系统从 URL 解析的查询字符串值、表单值和参数,统称为 context 数据。有三种访问 context 数据的主要方法。

  • 从一组 context 对象中提取
  • 将数据作为 action 方法的参数接收
  • 显式调用框架的模型绑定特性

在这里,我关注通过 action 方法获取输入的途径,重点是使用 context 对象和 action 方法参数。并在第26章中介绍模型绑定。

从 Context 对象获取数据

使用Controller基类创建控制器的主要优点之一是方便地访问一组 context 对象,它们描述当前请求、正在准备的响应和应用程序状态的。在表17-3中,我描述了最有用的控制器 context 属性。

表 17-3:用于 Context 数据的有用的控制器类属性

名称 描述
Request 此属性返回一个HttpRequest对象,该对象描述从客户端接收的请求,如表17-4所述。
Response 此属性返回一个HttpResponse对象,该对象用于创建对客户端的响应,如表17-7所述。
HttpContext 此属性返回一个HttpContext对象,它是其他属性(如RequestResponse)返回的许多对象的源。它还提供有关 HTTP 可用特性和访问 web 套接字等低级功能的信息。
RouteData 该属性返回路由系统匹配请求时生成的RouteData对象,如第15章和第16章所述。
ModelState 此属性返回ModelStateDictionary对象,该对象用于验证客户端发送的数据,如第27章所述。
User 该属性返回一个ClaimsPrincipal对象,该对象描述了提出请求的用户,如第29章和第30章所述。

许多控制器的编写不需要使用表17-3中所示的属性,因为 context 数据也可以通过我在后面章节中描述的特性获得,这些特性更符合 MVC 开发风格。例如,大多数控制器不需要使用Request属性来获取正在处理的 HTTP 请求的详细信息,因为通过我在第26章中描述的模型绑定过程可以获得相同的信息。

但是理解和使用 context 对象仍然是有用的,它们对于调试也很有用。在清单17-10中,我使用了Request属性来访问 HTTP 请求中的 header。

清单 17-10:Controllers 文件夹下的 DerivedController.cs 文件,使用 Context 数据

using Microsoft.AspNetCore.Mvc;
using System.Linq;

namespace ControllersAndActions.Controllers
{
    public class DerivedController : Controller
    {
        public ViewResult Index() =>
            View("Result", $"This is a derived controller");

        public ViewResult Headers() => View("DictionaryResult",
            Request.Headers.ToDictionary(kvp => kvp.Key,
                kvp => kvp.Value.First()));
    }
}

使用 context 对象意味着通过一系列不同类型和命名空间进行导航。用于获取清单中的 HTTP 请求的 context 数据的Controller.Request属性返回一个HttpRequest对象。表17-4描述了在编写控制器时最有用的HttpRequest属性。

表 17-4:常用的 HttpRequest 属性

名称 描述
Path 此属性返回请求 URL 的路径部分
QueryString 此属性返回请求 URL 的查询字符串部分
Headers 此属性返回请求 header 的字典,按名称索引
Body 此属性返回可用于读取请求 body 的流
Form 此属性返回按名称索引的请求中表单数据的字典
Cookies 此属性返回按名称索引的请求 cookies 的字典

我使用Request.Headers属性获得 header 的字典,然后使用 LINQ 进行了处理。

...
View("DictionaryResult", Request.Headers.ToDictionary(kvp => kvp.Key,
    kvp => kvp.Value.First()));
...

Request.Headers属性返回的字典使用StringValues结构体存储每个 header 的值,该结构体在 ASP.NET 中用于表示字符串值的序列。HTTP 客户端可以为 HTTP header 发送多个值,但我只想显示第一个值。我使用 LINQ 的ToDictionary方法接收每个 header 的KeyValuePair<string, StringValues>对象,并选择第一个值。结果是包含字符串值的字典,可以由 DictionaryResult 视图显示。如果您运行应用程序并请求 /Derived/Headers URL,将看到类似于图17-4所示的输出(header 集及其值将根据您使用的浏览器而有所不同)。

图17-4 显示 context 数据

从 POCO 控制器获取 Context 数据

即使它们在常规项目中并不特别有用,POCO 控制器也让我们在幕后窥探 MVC 是如何动作的。在 POCO 控制器中获取 context 数据是一个问题,因为您不能仅仅实例化您自己的HttpRequestHttpResponse对象;还需要 ASP.NET 创建并在处理请求时填充其数据字段的所有中间件组件更新的那些。

要获取 context 数据,POCO 控制器必须要求 MVC 提供。在清单17-11中,我更新了PocoController类以添加显示 HTTP 请求 header 的 action 方法。

清单 17-11:Controllers 文件夹下的 PocoController.cs 文件,显示 Context 数据

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System.Linq;

namespace ControllersAndActions.Controllers
{
    public class PocoController
    {
        [ControllerContext]
        public ControllerContext ControllerContext { get; set; }
        public ViewResult Index() => new ViewResult()
        {
            ViewName = "Result",
            ViewData = new ViewDataDictionary(new EmptyModelMetadataProvider(),
                new ModelStateDictionary())
            {
                Model = $"This is a POCO controller"
            }
        };

        public ViewResult Headers() =>
            new ViewResult()
            {
                ViewName = "DictionaryResult",
                ViewData = new ViewDataDictionary(
                    new EmptyModelMetadataProvider(),
                    new ModelStateDictionary())
                {
                    Model = ControllerContext.HttpContext.Request.Headers
                            .ToDictionary(kvp => kvp.Key, kvp => kvp.Value.First())
                }
            };
    }
}

为获取 context 数据,我定义了一个名为ControllerContext的属性,它的类型为ControllerContext,被一个名为ControllerContext的特性修饰。

了解这三种不同用途的ControllerContext术语是值得的。首先是ControllerContext类,它是在Microsoft.AspNetCore.Mvc命名空间中定义的,它使用表17-5中描述的属性将控制器的 action 方法所需的所有 context 对象集合在一起。

表 17-5:最重要的 ControllerContext 属性

名称 描述
ActionDescriptor 此属性返回一个ActionDescriptor对象,用于描述 action 方法。
HttpContext 此属性返回一个HttpContext对象,该对象提供 HTTP 请求和将作为返回发送的 HTTP 响应的详细信息。详情见表17-6。
ModelState 如第27章所述,此属性返回一个ModelStateDictionary对象,该对象用于验证客户端发送的数据。
RouteData 此属性返回一个RouteData对象,该对象描述路由系统处理请求的方式,如第15章所述。

HTTP 相关的数据是通过ControllerContext.HttpContext属性访问的,该属性返回一个Microsoft.AspNetCore.Http.HttpContext对象。HttpContext类合并了几个描述请求不同方面的对象,这些对象通过表17-6中所示的属性访问。

表 17-6:常用的HttpContext属性

名称 描述
Connection 此属性返回一个ConnectionInfo对象,它用于描述到客户端的低层次连接。
Request 此属性返回一个HttpRequest对象,用于描述从客户端接收的 HTTP 请求,如本章前面所述。
Response 此属性返回一个HttpResponse对象,用于创建将返回给客户端的响应,如《生成响应》这一节所述。
Session 此属性返回一个ISession对象,用于的描述与请求关联的会话
User 如第28章所述,此属性返回一个ClaimsPrincipal对象,用于描述与请求关联的用户

ControllerContext特性用于修饰清单17-11中的属性,并告诉 MVC 使用描述当前请求的ControllerContext对象设置属性值。它使用了我在第18章中描述的称为依赖注入的技术,MVC 将在使用 action 方法处理请求之前使用此属性向控制器提供 context 数据。

最后,第三个ControllerContext术语是属性的名称。您可以在自己的 POCO 控制器中使用任意合法的 C# 属性名,但是我选择了这个名称,因为它是Controller类使用的名称。在幕后,Controller类的 context 数据依赖于同一个ControllerContext类,该数据由相同的ControllerContext特性修饰。我在表17-3中描述的所有Controller属性都是直接使用ControllerContext属性的更方便、更简洁的替代品,这正是Controller类提供的属性中所发生的事情。作为例子,下面是Controller类中HttpContext属性的定义:

...
public HttpContext HttpContext {
    get {
        return ControllerContext.HttpContext;
    }
}
...

HttpContext属性只是获取ControllerContext.HttpContext属性值的一种更方便的方法。Controller基类并没有魔力:它使得控制器更简单、更清晰,因为它将公共任务合并成方便的方法和属性,如果需要,所有这些都可以在 POCO 控制器中重新创建。当您深入了解细节时,ASP.NET Core MVC 中的许多功能非常简单,并没有特殊的酱汁 —— 只是在精心设计的 NuGet 包中提供了深思熟虑的功能。如果您有时间,我建议您自己通过从http://github.com/aspnet下载 MVC 源代码并进行探索来确认这一点。

使用 Action 方法参数

还可以通过 action 方法参数接收一些 context 数据,这些参数可以生成更自然、更优雅的代码。一个常见的例子是,action 方法需要接收用户提交的表单数据值。为了进行比较,我将演示如何通过 context 对象获得表单数据,然后通过 action 方法参数获取表单数据。

表单数据值是通过Controller类的Request.Form属性访问的。为了演示,我添加了一个名为 HomeController.cs 的类文件,并使用它来定义派生控制器,如清单17-12所示。

清单 17-12:Controllers 文件夹下的 HomeController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        public ViewResult ReceiveForm()
        {
            var name = Request.Form["name"];
            var city = Request.Form["city"];
            return View("Result", $"{name} lives in {city}");
        }
    }
}

此控制器中的Index action 方法渲染我在第一章开始时在 Views/Shared 文件夹中创建的 SimpleForm 视图。让人感兴趣的是ReceiveForm方法,因为它使用HttpRequest context 对象从请求中获取表单数据值。

如表17-4所述,HttpRequest类定义的Form属性返回包含表单数据值的集合,该集合由相关联的 HTML 元素的名称索引。在 SimpleForm 视图(name 和 city)中有两个input元素,我从 context 对象中提取它们的值,并使用它们创建一个字符串,该字符串作为其模型传递给 Result 视图。

如果运行应用程序并请求 /Home URL,将展现一个表单。如果您填写了字段并单击【Submit】按钮,浏览器将发送表单数据作为 HTTP POST 请求的一部分,该请求将由ReceiveForm方法处理,产生如图17-5所示的结果。

图17-5 从 context 对象获取表单数据

清单17-12中所示的这种方法工作得很好,但是还有一种更优雅的方法。Action 方法可以定义 MVC 用来将 context 数据传递给控制器的参数,包括 HTTP 请求的详细信息。这比直接从 context 对象中提取它更为简单,并且它产生了更易于阅读的 action 方法。要接收表单数据,请在 action 方法上声明其名称与表单数据值相对应的参数,如清单17-13所示。

清单 17-13:Controllers 文件夹下的 HomeController.cs 文件,接收 Context 数据作为参数

using Microsoft.AspNetCore.Mvc;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        public ViewResult ReceiveForm(string name, string city)
            => View("Result", $"{name} lives in {city}");
    }
}

修改后的 action 方法产生相同的结果,但是代码更容易阅读和理解。MVC 将通过自动检查上下文对象(包括Request.QueryStringRequest.FormRouteData.Values)为 action 方法参数提供值。参数的名称不区分大小写,例如可以通过Request.Form["City"]的值填充名为city的 action 方法参数。这种方法还会产生更容易进行单元测试的 action 方法,因为 action 方法所操作的值是作为常规 C# 参数接收的,不需要对 context 对象进行模拟。

生成响应

在 action 方法处理完请求后,它需要生成一个响应。有许多特性可用于从 action 方法生成输出,我将在下面的部分中对此进行描述。

使用 Context 对象生成响应

生成输出的最低级别方法是使用HttpResponse context 对象,这就是 ASP.NET Core 如何提供对发送给客户端的 HTTP 响应的访问。表17-7描述了HttpResponse类提供的基本特性,它在Microsoft.AspNetCore.Http命名空间中定义。

表 17-7:常用 HttpResponse 属性

名称 描述
StatusCode 用于设置响应的 HTTP 状态码
ContentType 用于设置响应的 Content-Type header
Headers 返回将包含在响应中的 HTTP header 的字典
Cookies 返回用于向响应添加 cookies 的集合
Body 返回一个System.IO.Stream对象,用于为响应编写 body 数据。

在清单17-14中,我更新了 Home 控制器,使其ReceivedForm action 使用通过Controller.Request属性返回的HttpResponse对象生成响应。

清单 17-14:Controllers 文件夹下的 HomeController.cs 文件,生成响应

using Microsoft.AspNetCore.Mvc;
using System.Text;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        public void ReceiveForm(string name, string city)
        {
            Response.StatusCode = 200;
            Response.ContentType = "text/html";
            byte[] content = Encoding.ASCII
                .GetBytes($"<html><body>{name} lives in {city}</body>");
            Response.Body.WriteAsync(content, 0, content.Length);
        }
    }
}

这是生成响应的糟糕方法,因为它在 action 方法中使用 C# 字符串对 HTML 进行硬编码。这很容易出错,很难进行单元测试,但它确实为理解如何在幕后创建响应提供了一个起点。

与直接使用HttpResponse对象相比,还有更好的替代方法。MVC 构建在低级别响应的基础上,提供了一个更有用的特性,这是控制器工作方式的核心:action result

理解 Action Results

MVC 使用 action results 来区分陈述意图和执行意图。一旦你掌握了这个概念,它就变得很简单,但一开始需要一段时间才能理解这个方法,因为它存在一些间接因素。

与直接使用HttpResponse对象不同,action 方法返回一个对象,该对象实现了Microsoft.AspNetCore.Mvc命名空间中的IActionResult接口。IActionResult对象 —— 称为action result —— 描述来自控制器的响应应该是什么,例如渲染视图或将客户端重定向到另一个 URL。但是 —— 这就是间接因素 —— 不是直接生成响应。相反,MVC 处理 action result 来为您生成结果。

注意:action result 系统就是命令模式的一个例子。此模式描述了存储和分发那些描述要执行的操作的对象的场景。有关更多细节,请参见 http://en.wikipedia.org/wiki/Command_pattern

以下是来自 MVC 源码的IActionResult接口的定义:

using System.Threading.Tasks;
namespace Microsoft.AspNetCore.Mvc {

    public interface IActionResult {
        Task ExecuteResultAsync(ActionContext context);
    }
}

这个接口看起来很简单,但这是因为 MVC 并没有规定一个 action result 可以产生什么样的响应。当 action 方法返回 action result 时,MVC 调用它的ExecuteResultAsync方法,该方法负责代表 action 方法生成响应。ActionContext参数提供用于生成响应的 context 数据,包括HttpResponse对象(ActionContext类是ControllerContext的超类,定义了表17-5中描述的所有属性)。

为了演示 action results 是如何工作的,我向项目添加了一个 Infrastructure 文件夹,并向其添加了一个名为 CustomHtmlResult.cs 的类文件,用于定义清单17-15中所示的 action result。

清单 17-15:Infrastructure 文件夹下的 CustomHtmlResult.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using System.Text;
using System.Threading.Tasks;

namespace ControllersAndActions.Infrastructure
{
    public class CustomHtmlResult : IActionResult
    {
        public string Content { get; set; }

        public Task ExecuteResultAsync(ActionContext context)
        {
            context.HttpContext.Response.StatusCode = 200;
            context.HttpContext.Response.ContentType = "text/html";
            byte[] content = Encoding.ASCII.GetBytes(Content);
            return context.HttpContext.Response.Body.WriteAsync(content,
                0, content.Length);
        }
    }
}

CustomHtmlResult类实现了IActionResult接口,它的ExecuteResultAsync方法使用HttpResponse对象来编写包含名为Content的属性值的 HTML 响应。ExecuteResultAsync方法必须返回一个Task,以便异步生成响应;这与CustomHtmlResult类中的实现非常吻合,该类依赖于表示响应体的Stream对象的WriteAsync方法,它返回我可以用作方法结果的Task方法。

在清单17-16中,我将 action result 类应用于 Home 控制器,简化了 Hoem 控制器的ReceiveForm action 方法。

清单 17-16:Controllers 文件夹下的 HomeController.cs 文件,使用一个 Action Result

using Microsoft.AspNetCore.Mvc;
using System.Text;
using ControllersAndActions.Infrastructure;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        public void ReceiveForm(string name, string city)
            => new CustomHtmlResult
            {
                Content = $"{name} lives in {city}"
            };
    }
}

发送响应的代码现在与响应包含的数据分开定义,这简化了 action 方法,并允许在其他 action 方法中生成相同类型的响应,而无需重复相同的代码。


单元测试控制器和 action

ASP.NET Core MVC的许多部分都是为了方便单元测试而设计的,对于 action 和控制器来说尤其如此。有几个支持的原因。

  • 您可以在 web 服务器之外测试 action 和控制器。
  • 您不需要解析任何 HTML 来测试 action 方法的结果。您可以检查返回的IActionResult对象,以确保收到了预期的结果。
  • 您不需要模拟客户端请求。MVC 模型绑定系统允许您编写接收输入的 action 方法作为方法参数。要测试 action 方法,只需直接调用 action 方法并提供您感兴趣的参数值。

在本章中,我将向您展示如何为不同类型的 action results 创建单元测试。参见第7章,以了解如何设置单元测试项目或从本书的 GitHub 存储库下载示例项目(https://github.com/apress/pro-asp.net-core-mvc-2)。


生成 HTML 响应

在上一节中,我能够使用 action result 在控制器类外提取生成响应的代码。ASP.NET Core MVC 提供了一种更灵活的方法来生成响应:ViewResult类。

ViewResult类是提供对 Razor 视图引擎的访问的 action result,它处理 .cshtml 文件以合并模型数据,并通过HttpResponse context 引擎将结果发送给客户端。我在第21章中解释了视图引擎是如何工作的,但对于本章,我的重点是将ViewResult类用作 action result。

在清单17-17中,我已经将自定义 action result 类替换为ViewResult,它是通过Controller基类提供的View方法创建的。

清单 17-17:Controllers 文件夹下的 HomeController.cs 文件,使用ViewResult

using Microsoft.AspNetCore.Mvc;
using System.Text;
using ControllersAndActions.Infrastructure;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        public ViewResult ReceiveForm(string name, string city)
            => View("Result", $"{name} lives in {city}");
    }
}

正如我在本章开头的 POCO 控制器中所演示的,您可以直接创建ViewResult对象,但是使用View方法更简单、更简洁。Controller类提供了View方法的几个不同版本,这些版本允许选择将渲染的视图并提供模型数据,如表17-8所述。

表 17-8:控制器的 View 方法

方法 描述
View() 此方法为与 action 方法关联的默认视图创建一个ViewResult对象,这样,在名为MyAction的方法中调用View()将渲染一个名为 MyAction.cshtml 的视图。不使用模型数据。
View(view) 此方法创建一个ViewResult,它将渲染指定的视图,因此调用View("MyView")将渲染一个名为 MyView.cshtml 的视图。不使用模型数据。
View(model) 此方法为与 action 方法关联的默认视图创建一个ViewResult对象,并使用指定的对象作为模型数据。
View(view, model) 此方法为指定的视图创建一个ViewResult对象,并使用指定的对象作为模型数据。

如果运行应用程序并提交表单,您将看到熟悉的结果,如图17-6所示。

图17-6 使用 ViewResult 生成 HTML 响应

理解视图文件的搜索

当 MVC 调用ViewResult对象的ExecuteResultAsync方法时,将开始搜索您指定的视图。MVC 搜索视图的目录顺序是一个关于约定覆盖配置的例子。您不需要在框架中注册视图文件。只需将它们放在一组已知位置之一,框架就会找到它们。默认情况下,MVC 将在以下位置查找视图:

/Views/<ControllerName>/<ViewName>.cshtml
/Views/Shared/<ViewName>.cshtml

搜索以包含专用于当前控制器的视图的文件夹开始。该文件夹的名称省略了类名的 “Controller” 部分,因此HomeController类的文件夹是 Views/Home。

如果视图名称未在ViewResult对象中指定,则将使用路由数据中的action变量的值。对于大多数控制器来说,这意味着将使用该方法的名称,因此与Index方法关联的默认视图文件是Index.cshtml。但是,如果使用了Route特性,则与 action 方法关联的视图名称可能有所不同。

如果您的控制器是一个区域的一部分,如第16章所述,那么搜索位置是不同的。

/Areas/<AreaName>/Views/<ControllerName>/<ViewName>.cshtml
/Areas/<AreaName>/Views/Shared/<ViewName>.cshtml
/Views/Shared/<ViewName>.cshtml

MVC 依次检查这些文件中是否存在。一旦找到匹配,它就会使用该视图来渲染 action 方法的结果。我没有在示例项目中使用区域,所以清单17-17中的 action 方法导致 MVC 从 Views/Home/Result.cshtml 文件开始搜索。没有这样的文件,因此搜索继续,MVC 查找 Views/Shared/Result.cshtml,该文件确实存在,因此将用于渲染 HTML 响应。


单元测试:渲染视图 若要测试 action 方法渲染的视图,可以检查它返回的ViewResult对象。这并不是完全相同的事情(毕竟,您没有按照流程检查生成的最终 HTML),但是它足够接近,只要您对 MVC 视图系统正常工作有合理的信心。我向测试项目中添加了一个名为 ActionTests.cs 的新单元测试文件,以保存本章的单元测试。

我要测试的第一种情况是,action 方法选择特定视图时,如下所示:

public ViewResult ReceiveForm(string name, string city)
    => View("Result", $"{name} lives in {city}");
...

您可以通过读取ViewResult对象的ViewName属性来确定选择了哪个视图,如该测试方法所示:

using ControllersAndActions.Controllers;
using Microsoft.AspNetCore.Mvc;
using Xunit;

namespace ControllersAndActions.Tests {

    public class ActionTests {
        [Fact]
        public void ViewSelected() {
            // Arrange
            HomeController controller = new HomeController();

            // Act
            ViewResult result = controller.ReceiveForm("Adam", "London");

            // Assert
            Assert.Equal("Result", result.ViewName);
        }
    }
}

在测试选择默认视图的 action 方法时,会出现如下变化:

...
public ViewResult Result() => View();
...

在这种情况下,需要确保视图名为null,如下所示:

...
Assert.Null(result.ViewName);
...

null值是ViewResult对象如何向 MVC 发出信号,表示已经选择了与 action 方法关联的默认视图。



按路径指定视图

视图的命名约定方法是方便和简单的,但它确实限制了您可以渲染的视图。如果要渲染特定视图,可以通过提供显式路径并绕过搜索阶段来实现。下面是一个例子:

using Microsoft.AspNetCore.Mvc;

namespace ControllersAndActions.Controllers {
    public class ExampleController : Controller {
        public ViewResult Index() {
            return View("/Views/Admin/Index");
        }
    }
}

当您指定这样的视图时,路径必须以/~/开头,并且可以包含文件扩展名(如果未指定,则假定为.cshtml)。

如果你发现自己正在使用这个功能,我建议你花点时间问问自己你想要实现什么。如果您试图渲染属于另一个控制器的视图,则最好将用户重定向到该控制器中的 action 方法(有关示例,请参阅本章后面的《重定向到操作方法》这一节)。


将数据从 Action 方法传递到视图

当您使用ViewResult选择视图时,可以从生成 HTML 内容时使用的 action 方法传递数据。MVC 为 action 方法提供了将数据传递给视图的不同方法,我在下面的章节中对此进行了描述。这些功能自然地涉及到视图的主题,我在第21章中对此进行了深入的描述。在本章中,我只讨论足够的视图功能来演示控制器特性。

使用视图模型对象

通过将对象作为参数传递给View方法,可以将对象发送到视图,该方法的效果是设置创建的ViewResult对象的ViewData.Model属性。我在本章前面直接设置了这个属性,以解释 POCO 控制器是如何工作的,但是View方法更简洁地处理了这个问题。清单17-18显示了一个新的ExampleController类,我将它添加到Controllers文件夹中,并将一个视图模型对象传递给View方法。

清单 17-18:Controllers 文件夹下的 ExampleController.cs 文件的内容

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index() => View(DateTime.Now);
    }
}

我向View方法传递了一个DateTime对象作为视图模型。为了从视图中访问对象,我使用了Razor Model关键字。我创建了 Views/Example 文件夹,并添加了一个名为 Index.cshtml 的视图,如清单17-19所示。

清单 17-19:Views/Example 文件夹下的 Index.cshtml 文件的内容

@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/bootstrap/dist/css/*.min.css" />
</head>
<body class="m-1 p-1">
    Model: @(((DateTime)Model).DayOfWeek)
</body>
</html>

这是一个非类型化或弱类型视图。视图不知道任何关于视图模型对象的信息,并将其视为object实例。要获得DayOfWeek属性的值,我需要将该对象转换为DateTime实例,如下所示:

...
Model: @(((DateTime)Model).DayOfWeek)
...

这可以工作,但会产生混乱的视图。我可以通过创建强类型的视图来整理这些视图,其中视图包含视图模型对象类型的详细信息,如清单17-20所示。

清单 17-20:Views/Example 文件夹下的 Index.cshtml 文件,添加强类型

@model DateTime
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/bootstrap/dist/css/*.min.css" />
</head>
<body class="m-1 p-1">
    Model: @Model.DayOfWeek
</body>
</html>

我使用 Razor model关键字指定了视图模型类型。注意,我在指定模型类型时使用小写m,在读取值时使用大写M

强类型不仅有助于清理视图,而且 Visual Studio 支持强类型视图的智能感知,如图17-7所示。

图17-7 强类型视图的智能感知支持


单元测试:视图模型对象

视图模型对象被分配给ViewResult.ViewData.Model属性,这意味着在使用View方法时,可以测试 action 方法是否发送预期的数据。下面是一个测试方法,用于检查清单17-20中的 action 方法的模型类型:

...
[Fact]
public void ModelObjectType() {
    //Arrange
    ExampleController controller = new ExampleController();

    // Act
    ViewResult result = controller.Index();

    // Assert
    Assert.IsType<System.DateTime>(result.ViewData.Model);
}
...

Assert.IsType方法用于检查视图模型对象是否是DateTime实例。


在使用View方法时,有一个问题需要注意,当您希望使用与 action 关联的默认视图并为该视图提供一个String模型对象时,就会出现这种情况,如清单17-21所示。

清单 17-21:Controllers 文件夹下的 ExampleController.cs 文件,使用视图模型

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index() => View(DateTime.Now);

        public ViewResult Result() => View("Hello World");
    }
}

在新的Result action 方法中,我希望使用View方法来渲染 action 的默认视图,并指定模型数据,这是表17-8中方法的第三个版本。但是,如果您运行该应用程序并请求 /Example/Result URL,将看到一个类似于此的错误:

InvalidOperationException: The view 'Hello, World' was not found.
The following locations were searched:
/Views/Example/Hello, World.cshtml
/Views/Shared/Hello, World.cshtml

问题是,我对带有字符串的View方法的调用与表17-8中的视图方法的第二个版本匹配,这意味着字符串参数被解释为要渲染的视图的名称,因此 MVC 试图找到一个名为【Hello, World.cshtml】的视图文件,而不是【Result.cshtml】。这是一个常见的问题,但通过将模型数据转换为对象很容易修复,如清单17-22所示。

清单 17-22:Controllers 文件夹下的 ExampleController.cs 文件,选择正确的 View 方法

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index() => View(DateTime.Now);

        public ViewResult Result() => View((object)"Hello World");
    }
}

显式地将模型数据转换为object,确保调用View方法的匹配的正确版本,并渲染 Result.cshtml`文件。

使用 View Bag 传递数据

我在第2章中介绍了 View Bag 特性。这个特性允许您在动态对象上定义属性并在视图中访问它们。动态对象是通过Controller类提供的ViewBag属性访问的,如清单17-23所示。

清单 17-23:Controllers 文件夹下的 ExampleController.cs 文件,使用 View Bag 特性

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index()
        {
            ViewBag.Message = "Hello";
            ViewBag.Date = DateTime.Now;
            return View();
        }

        public ViewResult Result() => View((object)"Hello World");
    }
}

我已经定义了名为MessageDate的 view bag 属性,给它们赋值。在此之前,不存在这样的属性,我也没有做过任何准备来创建它们。要在视图中读取数据,需要使用 action 方法中设置的相同属性,如清单17-24所示。

清单 17-24:Views/Example 文件夹下的 Index.cshtml 文件,从 ViewBag 中读取数据

@model DateTime
@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" asp-href-include="lib/bootstrap/dist/css/*.min.css" />
</head>
<body class="m-1 p-1">
    <p>The day is: @ViewBag.Date.DayOfWeek</p>
    <p>The message is: @ViewBag.Message</p>
</body>
</html>

与使用视图模型对象相比,ViewBag有一个优点,因为它很容易将多个对象发送到视图。如果 MVC 只支持视图模型,那么我需要创建一个具有stringDateTime成员的新类型,以获得相同的效果。

警告:Visual Studio 不能为任何动态对象(包括ViewBag)提供智能感知支持,并且在渲染视图之前不会显示错误。


单元测试:ViewBag

ViewResult.ViewData属性返回一个字典,其键是 action 方法定义的 view bag 属性的名称。下面是清单17-24中 action 方法的测试方法:

...
[Fact]
public void ModelObjectType() {
    //Arrange
    ExampleController controller = new ExampleController();

    // Act
    ViewResult result = controller.Index();

    // Assert
    Assert.IsType<string>(result.ViewData["Message"]);
    Assert.Equal("Hello", result.ViewData["Message"]);
    Assert.IsType<System.DateTime>(result.ViewData["Date"]);
}
...

此测试方法使用Assert.IsType方法检查MessageDate属性的类型,并使用Assert.Equal方法检查Message属性的值。


执行重定向

action 方法的一个常见结果不是直接产生任何输出,而是将客户端重定向到另一个 URL。大多数情况下,这个 URL 是应用程序中的另一个 action 方法,它生成的输出是您希望用户看到的。执行重定向时,需要向浏览器发送两个 HTTP 状态码中的一个。

HTTP 状态码302,这是一个临时重定向。这是最常用的重定向类型,当使用 Post/Redirect/Get 模式时,这是您想要发送的状态码。

HTTP 状态码301,表示永久的重定向。这应该谨慎使用,因为它指示 HTTP 代码的接收方不再请求原始 URL,并使用与重定向代码包含在一起的新 URL。如果您有疑问,请使用临时重定向;也就是说,发送状态码302。

几个不同的 action results 可用于执行重定向,如表17-9所述。

表 17-9:重定向 Action Results

名称 控制器方法 描述
RedirectResult Redirect
RedirectPermanent
此 action result 发送带有HTTP 301或302状态代码的响应,将客户端重定向到新的 URL。
LocalRedirectResult LocalRedirect
LocalRedirectPermanent
此 action result 将客户端重定向到本地 URL
RedirectToActionResult RedirectToAction
RedirectionToActionPermanent
此 action result 将客户端重定向到特定的 action 和控制器。
RedirectToRouteResult RedirectToRoute
RedirectToRoutePermanent
此 action result 将客户端重定向到从指定路由生成的 URL。

重定向到字面量 URL

重定向浏览器的最基本方法是调用Controller类提供的Redirect方法,它返回RedirectResult类的实例,如清单17-25所示。

清单 17-25:Controllers 文件夹下的 ExampleController.cs 文件,重定向到字面量 URL

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index()
        {
            ViewBag.Message = "Hello";
            ViewBag.Date = DateTime.Now;
            return View();
        }

        public ViewResult Result() => View((object)"Hello World");

        public RedirectResult Redirect() => Redirect("/Example/Index");
    }
}

重定向 URL 表示为Redirect方法的字符串参数,该方法产生临时重定向。您可以使用RedirectPermanent方法执行永久重定向,如清单17-26所示。

提示LocalRedirectionResult是一个可选的 action result,如果控制器试图对任何非本地的 URL 执行重定向,则该结果将引发异常。当您重定向到用户提供的 URL 时,这是非常有用的,在那里,*开放重定向攻击(open redirection attack )*试图将另一个用户重定向到不受信任的站点。这种 action result 可以通过从Controller类继承的LocalRedirect方法来创建。

清单 17-26:Controllers 文件夹下的 ExampleController.cs 文件,永久重定向

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index()
        {
            ViewBag.Message = "Hello";
            ViewBag.Date = DateTime.Now;
            return View();
        }

        public ViewResult Result() => View((object)"Hello World");

        public RedirectResult Redirect() => RedirectPermanent("/Example/Index");
    }
}

单元测试:字面量重定向

字面量重定向很容易测试。您可以使用RedirectResult类的Permanent属性读取 URL 并测试重定向是永久的还是临时的。下面是清单17-26所示的永久重定向的测试方法:

...
[Fact]
public void Redirection() {
    // Arrange
    ExampleController controller = new ExampleController();
    // Act
    RedirectResult result = controller.Redirect();
    // Assert
    Assert.Equal("/Example/Index", result.Url);
    Assert.True(result.Permanent);
}
...

注意,在调用 action 方法时,我已经更新了测试以接收RedirectResult


重定向到路由系统 URL

如果要将用户重定向到应用程序的其他部分,则需要确保您发送的 URL 在 URL 架构中是有效的。使用字面量 URL 进行重定向的问题是,路由架构中的任何更改都意味着您需要遍历代码并更新 URL。幸运的是,您可以使用RedirectToRoute方法让路由系统生成有效的 URL,这将创建RedirectToRouteResult的一个实例,如清单17-27所示。

提示:如果您按顺序遵循本章中的示例,则可能必须清除浏览器的历史记录,才能使清单17-27中的代码正常工作。这是因为浏览器记住清单17-26中的永久重定向,并将 /Example/Redirect URL 的请求转换为一个到 /Example/Index 的请求,而不与服务器联系。

清单 17-27:Controllers 文件夹下的 ExampleController.cs 文件,重定向到路由 URL

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index()
        {
            ViewBag.Message = "Hello";
            ViewBag.Date = DateTime.Now;
            return View();
        }

        public ViewResult Result() => View((object)"Hello World");

        public RedirectToRouteResult Redirect() =>
            RedirectToRoute(new
            {
                controller = "Example",
                action = "Index",
                ID = "MyID"
            });
    }
}

RedirectToRoute方法发出一个临时重定向。使用RedirectToRoutePermanent方法进行永久重定向。两种方法都采用匿名类型,其属性随后被传递给路由系统以生成 URL,如第16章所述。


单元测试:路由重定向

以下为清单17-27中的 action 方法的单元测试:

...
[Fact]
public void Redirection() {
    // Arrange
    ExampleController controller = new ExampleController();
    // Act
    RedirectToRouteResult result = controller.Redirect();
    // Assert
    Assert.False(result.Permanent);
    Assert.Equal("Example", result.RouteValues["controller"]);
    Assert.Equal("Index", result.RouteValues["action"]);
    Assert.Equal("MyID", result.RouteValues["ID"]);
}
...

我通过查看RedirectToRouteResult对象提供的路由信息间接测试了结果,这意味着我不必解析 URL,这将要求单元测试对应用程序使用的 URL 架构进行假设。


重定向至 Action 方法

通过使用RedirectToAction方法(用于临时重定向)或RedirectToActionPermanent方法(用于永久重定向),您可以更优雅地重定向到 action 方法。这些只是RedirectToRoute方法的包装器,它允许您为 action 方法和控制器指定值,而无需创建匿名类型,如清单17-28所示。

清单 17-28:Controllers 文件夹下的 ExampleController.cs 文件,使用 RedirectToAction 方法

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ViewResult Index()
        {
            ViewBag.Message = "Hello";
            ViewBag.Date = DateTime.Now;
            return View();
        }

        public RedirectToActionResult Redirect() => RedirectToAction(nameof(Index));
    }
}

如果只指定一个 action 方法,则假设您引用的是当前控制器中的 action 方法。如果要重定向到另一个控制器,则需要提供控制器的名称作为参数,如下所示:

...
public RedirectToActionResult Redirect()
    => RedirectToAction(nameof(HomeController), nameof(HomeController.Index));
...

还可以使用其他重载版本为 URL 生成提供附加值。这些都是用匿名类型表示的,这确实会破坏方法的便利性,但仍然可以使代码更容易阅读。

注意:您为 action 方法和控制器提供的值在传递给路由系统之前不会被验证。您负责确保指定的目标确实存在。


单元测试:Action 方法重定向

以下是对清单17-28中的 action 方法的单元测试

...
[Fact]
public void Redirection() {
    // Arrange
    ExampleController controller = new ExampleController();
    // Act
    RedirectToActionResult result = controller.Redirect();
    // Assert
    Assert.False(result.Permanent);
    Assert.Equal("Index", result.ActionName);
}
...

RedirectToActionResult类提供了ControllerNameActionName属性,能在不解析 URL 的情况下方便地检查控制器创建的重定向。


使用 Post/Redirect/Get 模式

最常用的重定向是处理 HTTP POST 请求的 action 方法。正如我在上一章中解释的那样,当您想要更改应用程序的状态时,使用 POST 请求。如果您只是在处理 POST 请求后返回 HTML 响应,则存在这样的风险:用户将单击浏览器的【重新加载】按钮并再次提交表单,这可能会产生意外和不理想的结果。

您可以在示例应用程序中的 Home 控制器中看到这个问题。ReceiveForm方法接受的参数是从表单数据获得值,并使用View方法返回ViewResult

...
public ViewResult ReceiveForm(string name, string city)
    => View("Result", $"{name} lives in {city}");
...

要查看问题,请运行应用程序并请求 /Home URL。提交表单,然后单击浏览器重的【重新加载】按钮。使用 F12 工具来研究浏览器发出的 HTTP 请求,您将看到一个新的 POST 请求被发送到服务器。对于简单的应用程序这样不会产生任何影响,但如果 POST 请求最终重复删除数据、提交订单或执行其他用户不打算执行的重要任务,这个问题可能会造成严重破坏。

为了避免这个问题,您可以遵循称为 Post/Redirect/Get 的模式。在此模式中,您接收一个 POST 请求,处理它,然后重定向浏览器,以便浏览器对另一个 URL 发出 GET 请求。GET 请求不应该修改应用程序的状态,因此此请求的任何意外重提交都不会导致任何问题。在清单17-29中,我添加了一个重定向,以便浏览器用 GET 请求重定向到一个不同的 URL。

清单 17-29:Controllers 文件夹下的 HomeController.cs 文件,Post/Redirect/Get 模式

using Microsoft.AspNetCore.Mvc;
using System.Text;
using ControllersAndActions.Infrastructure;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        [HttpPost]
        public RedirectToActionResult ReceiveForm(string name, string city)
            => RedirectToAction(nameof(Data));

        public ViewResult Data() => View("Result");
    }
}

RedirectToActionResult方法通过 POST 请求接收来自用户的数据,并将客户端重定向到Data action 方法。如果用户重新加载页面,将向Data action 方法发送无害的 GET 请求。我在第20章中描述的HttpPost特性确保只有 POST 请求才能发送到ReceiveForm操作。

使用 Temp Data

重定向会导致浏览器发送全新的 HTTP 请求,这意味着无法访问原始请求中的表单数据。使得Data方法不知道应该向用户显示的namecity值。

可以使用 temp data 功能来将数据从一个请求保持到另一个请求。temp data 类似于我在第9章中使用的会话数据,只是在读取时标记为删除,而在处理请求时从数据存储中删除。这是在 Post/Redirect/Get 模式中进行重定向工作所需的短期数据的理想安排。temp data 特性可以通过名为TempDataController类属性获得,如清单17-30所示。

注意:temp data 依赖于会话中间件。请参阅本章的开头,以了解Startup类中此功能所需的中间件组件。

清单 17-30:Controllers 文件夹下的 HomeController.cs 文件,使用 temp data

using Microsoft.AspNetCore.Mvc;
using System.Text;
using ControllersAndActions.Infrastructure;

namespace ControllersAndActions.Controllers
{
    public class HomeController : Controller
    {
        public ViewResult Index() => View("SimpleForm");

        [HttpPost]
        public RedirectToActionResult ReceiveForm(string name, string city)
        {
            TempData["name"] = name;
            TempData["city"] = city;
            return RedirectToAction(nameof(Data));
        }

        public ViewResult Data()
        {
            string name = TempData["name"] as string;
            string city = TempData["city"] as string;
            return View("Result", $"{name} lives in {city}");
        }
    }
}

ReceiveForm方法在将客户端重定向到Data action 之前,使用TempData属性(返回一个字典)来存储namecity值。Data方法使用相同的TempData属性检索数据值,并使用它们创建将由视图显示的模型数据。

提示TempData字典还提供了一个Peek方法,它允许您获得一个数据值,而无需将其标记为删除。还提供了一个Keep方法,该方法可用于防止删除先前读取的值。Keep方法不会永久保护值。如果再次读取该值,它将再次被标记为删除。如果要存储项,请使用会话数据,以便在处理请求时不会删除它们。

返回不同类型的内容

HTML 不是您的 action 方法可以生成的唯一类型的响应,表17-10显示了可用于不同类型数据的内置 action results。

表 17-10:action results 的内容

名称 控制器方法 描述
JsonResult Json 此 action result 将对象序列化为 JSON 并将其返回给客户端。
ContentResult Content 此 action result 发送 body 包含指定对象的响应。
ObjectResult Not Available 此 action result 将使用内容协商将对象发送到客户端。
OkObjectResult Ok 此 action result 将使用内容协商将对象发送到客户端,如果内容协商成功,则携带 HTTP 200 状态码。
NotFoundObjectResult NotFound 此 action result 将使用内容协商将对象发送到客户端,如果内容协商成功,则携带 HTTP 404 状态码。

生成 JSON 响应

JavaScript Object Notation (Json)已成为在 web 应用程序与其客户端之间传输数据的标准方式。JSON 在很大程度上取代了 XML 作为数据交换格式,因为它更易于使用,特别是在编写客户端 JavaScript 时,因为 JSON 与 JavaScript 用于定义字面量数据值的语法密切相关。我返回到第20章中的 JSON 主题及其在 web 应用程序中的角色,清单17-31展示了使用Json方法创建JsonResult对象。

清单 17-31:Controllers 文件夹下的 ExampleController.cs 文件,生成 JSON 响应

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public JsonResult Index() => Json(new[] { "Alice", "Bob", "Joe" });
    }
}

运行该示例并请求 /Example URL,您将看到一个来自 action 方法的用 JSON 表示的 C# 字符串数组表达式,如下:

["Alice","Bob","Joe"]

大多数浏览器以内联方式显示 JSON 结果,但包括 Microsoft Internet Explorer 在内的一些浏览器要求您在检查数据之前将数据保存到文件中。


单元测试:NON-HTML Action Results

重要的是要记住,对 action 方法的单元测试应该集中在返回的要格式化的数据上,而不是格式化本身,这些数据由 MVC 处理,而且对于大多数测试项目来说通常超出范围。举个例子,下面是清单17-31中 action 方法的单元测试:

...
[Fact]
public void JsonActionMethod() {
    // Arrange
    ExampleController controller = new ExampleController();
    // Act
    JsonResult result = controller.Index();
    // Assert
    Assert.Equal(new[] { "Alice", "Bob", "Joe" }, result.Value);
}
...

JsonResult类提供了一个Value属性,该属性返回将转换为 JSON 的数据,以产生对客户端的响应。在单元测试中,我将Value属性与我期望的数据进行比较。


使用对象生成响应

许多应用程序只需要来自控制器的 HTML 和 JSON 响应,并且依赖于静态文件的支持来交付其他类型的内容,例如图像、 JavaScript 文件和 CSS 样式表。但是,在某些情况下,您需要在响应中返回特定的内容类型,并且有一些 action results 可用于帮助此操作。最简单的是ContentResult类,它是通过Content方法创建的,该方法用于发送带有可选 MIME 内容类型的字符串值。在清单17-32中,我使用了Content方法手动重新创建上一节中的 JSON 结果。

清单 17-32:Controllers 文件夹下的 ExampleController.cs 文件,手动创建一个 JSON 结果

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ContentResult Index()
            => Content("[\"Alice\",\"Bob\",\"Joe\"]", "application/json");
    }
}

这种类型的 action result 是有用的,当您有便于以字符串格式表示的内容,并且您知道客户端能够接受您指定的 MIME 类型时。这种方法的危险在于,您以客户端不知道如何处理的格式发送响应。一种更健壮的方法是依赖内容协商,该协商由ObjectResult执行,如清单17-33所示。

清单 17-33:Controllers 文件夹下的 ExampleController.cs 文件,使用内容协商

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public ObjectResult Index() => Ok(new string[] { "Alice", "Bob", "Joe" });
    }
}

内容协商这个术语暗示了在浏览器和应用程序之间找出一种通用格式的复杂系统,但实际上,它是一个简单的过程。当浏览器发出 HTTP 请求时,它包含Accept header,指示它可以处理哪些格式。下面是用于测试示例的 Google Chrome 版本的 header:

Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8

支持的格式表示为 MIME 类型。MVC 有一组可以用于数据值的格式,并将这些格式与浏览器支持的格式进行比较。MVC 使用的首选格式是 JSON,这将在大多数情况下使用,除非 action 返回字符串值,在这种情况下使用纯文本。有关内容协商过程和如何实现的详细信息,请参阅第20章。

以文件内容响应

大多数应用程序依赖于静态文件中间件来传递文件的内容,但是也存在一组 action results,如表17-11中所述,这些 action results 可用于将文件发送到客户端。

警告:使用这些 action results 时要小心,并确保没有创建允许请求任意文件内容的应用程序。特别是,不要从请求的任何部分或用户可以通过请求修改的任何数据存储中获取要发送的文件的路径。

表 17-11:文件 Action Results

名称 控制器方法 描述
FileContentResult File 此 action result 以指定的 MIME 类型向客户端发送一个字节数组。
FileStreamResult File 此 action result 读取流并将内容发送到客户端。
VirtualFileResult File 此 action result 从虚拟路径(相对于主机上的应用程序)读取流。
PhysicalFileResult PhysicalFile 此 action result 从指定的路径读取文件的内容,并将内容发送给客户端。

在清单17-34中,我使用了从Controller类继承的File方法来返回 Bootstrap CSS 文件,并把它作为 Example 控制器上的 Index action 方法的结果。

清单 17-34:Controllers 文件夹下的 ExampleController.cs 文件,使用文件作为响应

using Microsoft.AspNetCore.Mvc;
using System;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public VirtualFileResult Index()
            => File("/lib/twitter-bootstrap/css/bootstrap.css", "text/css");
    }
}

为了使用此 action 方法,我修改了 SimpleForm.cshtml 文件中的link元素,以便它使用 Url 助手,如清单17-35所示。

清单 17-35:SimpleForm.cshtml 文件,针对一个 Action 方法

@{ Layout = null; }

<!DOCTYPE html>
<html>
<head>
    <meta name="viewport" content="width=device-width" />
    <title>Controllers and Actions</title>
    <link rel="stylesheet" href="@Url.Action("Index", "Example")" />
</head>
<body class="m-1 p-1">
    <form method="post" asp-action="ReceiveForm">
        <div class="form-group">
            <label for="name">Name:</label>
            <input class="form-control" name="name" />
        </div>
        <div class="form-group">
            <label for="name">City:</label>
            <input class="form-control" name="city" />
        </div>
        <button class="btn btn-primary center-block" type="submit">Submit</button>
    </form>
</body>
</html>

如果运行该示例并请求 /Home URL,则发送给浏览器的 HTML 响应将包括以下元素:

<link rel="stylesheet" href="/Example" />

这将导致浏览器发送一个 HTTP 请求,该请求针对清单17-35中的 action 方法,该方法将发送视图中的内容样式所需的 CSS 文件。

注意:正如我在第25章中描述的那样,标签助手是交付 CSS 的一个更有用的工具。

返回错误和 HTTP 编码

最后一组内置的ActionResult类可以用于向客户端发送特定的错误消息和 HTTP 结果编码,如表17-12所述。大多数应用程序不需要这些特性,因为 ASP.NET Core 和 MVC 将自动生成这些结果。但是,如果您需要对发送给客户端的响应进行更直接的控制,它们可能很有用。

表 17-12:状态码 Action Result

名称 控制器方法 描述
StatusCodeResult StatusCode 此 action result 向客户端发送指定的 HTTP 状态码
OkResult Ok 此 action result 向客户端发送 HTTP 200 状态码
CreatedResult Created 此 action result 向客户端发送 HTTP 201 状态码
CreatedAtActionResult CreatedAtAction 此 action result 向客户端发送 HTTP 201 状态码并携带针对 action 和控制器的Location header 中的 URL
CreatedAtRouteResult CreatedAtRoute 此 action result 向客户端发送 HTTP 201 状态码并携带从特定路由生成的Location header 中的 URL
BadRequestResult BadRequest 此 action result 向客户端发送 HTTP 400 状态码
UnauthorizedResult Unauthorized 此 action result 向客户端发送 HTTP 401 状态码
NotFoundResult NotFound 此 action result 向客户端发送 HTTP 404 状态码
UnsupportedMediaTypeResult None 此 action result 向客户端发送 HTTP 415 状态码

发送指定的 HTTP 结果编码

您可以使用StatusCode方法向浏览器发送指定的 HTTP 状态码,这将创建一个StatusCodeResult对象,如清单17-36所示。

清单 17-36:Controllers 文件夹下的 ExampleController.cs 文件,发送一个指定状态码

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public StatusCodeResult Index()
            => StatusCode(StatusCodes.Status404NotFound);
    }
}

StatusCode方法接受int值,您可以使用该值直接指定状态代码。Microsoft.AspNetCore.Http命名空间中的StatusCodes类为 HTTP 支持的所有状态代码定义字段。在清单中,我使用Status404NotFound字段返回编码 404,这意味着所请求的资源不存在。

使用便捷类发送 404 结果

表17-12中显示的其他 action results 都扩展或依赖StatusCodeResult类,它提供了发送指定状态代码的更方便的方法。我可以使用更方便的NotFoundResult类实现与清单17-36相同的效果,该类派生自StatusCodeResult,可以使用控制器的NotFound便捷方法创建,如清单17-37所示。

清单 17-37:Controllers 文件夹下的 ExampleController.cs 文件,生成 404 结果

using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Http;

namespace ControllersAndActions.Controllers
{
    public class ExampleController : Controller
    {
        public StatusCodeResult Index() => NotFound();
    }
}

**单元测试:HTTP 状态码

StatusCodeResult类遵循其他结果类型的模式,并通过一组属性使其状态可用。在本例中,StatusCode属性返回数字 HTTP 状态码,StatusDescription属性返回相关的描述性字符串。以下测试方法用于清单17-37中的 action 方法:

...
[Fact]
public void NotFoundActionMethod() {
    // Arrange
    ExampleController controller = new ExampleController();
    // Act
    StatusCodeResult result = controller.Index();
    // Assert
    Assert.Equal(404, result.StatusCode);
}
...

理解其它 Action Result 类

一些附加的 action result 类与我在其他章节中描述的 MVC 特性密切相关。表17-13列出了这些类以及描述它们相关特性的章节。

表 17-13:其它 Action Result 类

名称 控制器方法 描述
PartialViewResult PartialView 此 action result 用于选择分部视图,如第21章所述。
ViewComponentResult ViewComponent 此 action result 用于选择视图组件,如第22章所述。
EmptyResult None 此 action result 类不执行任何操作,并生成对客户端的空响应。
ChallengeResult None 此 action result 用于在请求中强制执行安全策略。有关详细信息,请参阅第30章。

总结

控制器是 MVC 设计模式中的关键构建块之一,是 MVC 开发的核心。在本章中,您已经看到了如何使用基本的 C# 创建 POCO 控制器以及如何从Controller基类提供的方便中获益。您看到了 action results 在 MVC 控制器中扮演的角色,以及它们如何简化单元测试。我向您展示了从 action 方法接收输入和生成输出的不同方法,并演示了内置的 action result 让这一个过程变得简单而灵活。在下一章中,我将描述对 ASP.NET Core 开发人员最容易造成混乱,但对于有效的 MVC 开发至关重要的特性之一:依赖注入。

;

© 2018 - IOT小分队文章发布系统 v0.3